iT邦幫忙

2024 iThome 鐵人賽

DAY 12
0

https://ithelp.ithome.com.tw/upload/images/20240926/201682015Qq5mOAgV3.png

延續昨天提到的 Observer 模式,今天要介紹的是與 Observer 十分相似的延伸版,即 Publish/Subscribe 模式。GoF 一書中提到,Observer 模式也被稱作 Dependents 或 Publish-Subscribe。然而,隨著時間的推移,在現代實務中,Observer 和 Publish/Subscribe 模式之間存在一些細微差異。

Observer 模式與 Publish/Subscribe 模式的區別

Observer 模式

  • 緊密耦合:觀察者(Observer)和主體(Subject)間是緊密耦合的。觀察者要主動向主體訂閱他們感興趣的事件,會直接依賴主體,而主體會知道所有的觀察者,並在狀態改變時通知他們
    • 觀察者訂閱的是主體本身
  • 資料傳遞:一般來說,資料會直接從主體傳遞到觀察者,沒有中介層來處理或改變資料。但也不代表不能在傳遞過程修改資料,而是指典型的 Observer 模式並沒有專門的一層來處理這些資料
    • 例如,如果主體觸發的事件資料是 1,觀察者將直接接收到這個資料 1,除非主體在通知之前修改了資料

Publish/Subscribe 模式

  • 解耦:訂閱者(Subscriber)和發布者(Publisher)是完全解耦的。雙方透過一個主題或事件頻道(中介層)進行溝通,這代表訂閱者不會知道是哪個具體的發布者發出資料,發布者也不會知道有哪些訂閱者在接收資料
    • 訂閱者訂閱的是特定的主題或事件頻道,而不是直接訂閱具體的發布者
  • 資料傳遞:透過中介層傳遞資料時,資料可以在傳輸過程中被處理或修改,這讓發布者和訂閱者能夠更靈活地操作資料,而無需了解彼此的存在
    • 例如,發布者可能發布了資料 1,但中介層可以將其修改為 2,然後再傳遞給訂閱者。訂閱者不會知道原始的資料是 1,只會接收到修改後的 2

Publish/Subscribe 模式透過事件頻道來構建系統,允許我們根據不同應用場景的需求來定義事件,使系統更具靈活性和適應性。這種模式不僅能夠傳遞發布者的資料,還可以加入客製化的參數,並且有效避免訂閱者與發布者間的直接依賴關係。


以下為比較示意圖,簡言之兩者就是差在 Publish/Subscribe 多了一個頻道層,將主體和觀察者解耦,降低依賴關係。
https://ithelp.ithome.com.tw/upload/images/20240926/20168201uOASYrTbir.png
圖 1 Observer 和 Publish/Subscribe 差異(資料來源:自行繪製)

小補充,技術上來說,Observer 和 Publish/Subscribe 都可以在傳遞資料過程中修改要傳遞的資料,但 Observer 模式強調的是直接性和耦合性,而 Publish/Subscribe 模式則強調解耦和資料處理的靈活性。

Publish/Subscribe 範例

來試著實作看看 Publish/Subscribe 吧!
先建立一個 PubSub class,訂閱、發布和取消訂閱的方法,並儲存所有主題以及每個主題對應的訂閱者陣列。

class PubSub {
  constructor() {
    this.topics = {}; // 儲存主題
    this.subUid = 0; // 主題的 uid
  }

  publish(topic, args) { // 將特定主題內容發布給所有訂閱者
    if (!this.topics[topic]) {
      return false;
    }

    const subscribers = this.topics[topic]; // 找到該主題的訂閱者陣列
    let len = subscribers ? subscribers.length : 0;

    while (len--) { // 用迴圈執行訂閱者的 function 並傳入發布者要傳送的 data
      subscribers[len].func(topic, args);
    }

    return this;
  }

  subscribe(topic, func) {
    if (!this.topics[topic]) {
      this.topics[topic] = []; // 如果儲存主題區沒有該主題,就新建一個
    }

    const token = (++this.subUid).toString(); // 根據現有 uid 計算新的 id
    this.topics[topic].push({ // 將訂閱者推進該主題的訂閱者陣列,加入新的 id
      token,
      func,
    });

    return token; 
  }

  unsubscribe(token) { // 根據 token 值,刪除特定訂閱者
    for (const topic in this.topics) { // 遍歷主題物件
      if (this.topics.hasOwnProperty(topic)) { // 如果該主題存在物件內
        const subscribers = this.topics[topic]; // 找出該主題訂閱者
        for (let i = 0; i < subscribers.length; i++) { // 遍歷訂閱者找到符合 token 的,移除該訂閱者
          if (subscribers[i].token === token) {
            subscribers.splice(i, 1);
            return token;
          }
        }
      }
    }

    return this;
  }
}

接著以 PubSub 實例來建立訂閱與發佈的邏輯:

// 定義 messageLogger,這是訂閱者收到通知後要執行的函式
const messageLogger = (topics, data) => {
    console.log(`Logging: ${topics}: ${data}`)
}

// 訂閱 'inbox/newMessage' 這個主題,當收到這主題通知時就會執行 messageLogger
const subscription = pubsub.subscribe('inbox/newMessage', messageLogger);

// 發布者會發布主題,並傳送資料給訂閱者,資料格式自訂 但訂閱者要大概知道資料才知道如何在 callback 處理
pubsub.publish('inbox/newMessage', 'hello world!');
pubsub.publish('inbox/newMessage', {
    sender: 'hello@google.com',
    body: 'Hey again!'
});

// 可取消訂閱
// 一旦取消訂閱,之後發布該主題的事件也不會觸發 messageLogger
pubsub.unscribe(subscription)

Publish/Subscribe 在實務應用上也很常見,例如當我們要在前端介面上即時顯示股票資料的圖表和最後更新時間時,就可應用 Publish/Subscribe 模式。當股票資料改變時,對應的圖表和更新時間介面都需要同步更新。在這情境中,股票資料是發布者,而圖表和更新時間則是訂閱者。當訂閱者收到資料已更新的事件/主題時,它們就會執行相應的更新操作。

非同步請求與 Publish/Subscribe 應用

在前端應用中,我們經常需要向後端請求資料,並根據這些資料執行後續動作或渲染對應的畫面。然而,這些請求通常是非同步的,需要確保後續邏輯在請求成功後才執行。為了在成功取得資料後才執行後續邏輯,我們通常會將後續的邏輯放在請求成功的 callback 中,例如:

// 發送非同步的 API 請求
fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => {
    // 請求成功後執行後續邏輯
    console.log('data', data);
  })
  .catch(error => {
    console.error('請求失敗:', error);
  });

這種做法會增加函式或程式碼之間的依賴性,將請求與後續的邏輯緊密耦合。高耦合的程式碼不僅難以重用,當你需要在請求成功後發送另一個 API 請求並執行後續處理時,程式碼也會變得難以維護。
如何解決此問題? 可以考慮使用 Publish/Subscribe 模式,將不同事件的通知分離。這樣可以實現關注點分離,讓 API 請求專注於請求和傳回資料,而其他使用資料的邏輯則可以獨立處理,減少依賴性,提升程式碼的可重用性和可維護性。

以下是一個使用 Publish/Subscribe 模式與 API 請求結合的範例,我用了 The Rick and Morty API 作為資料來源。使用者可以在輸入框中搜尋角色名稱,當 API 請求成功後,將顯示出對應的搜尋結果。
首先要建立 HTML 結構,其中包含一個供使用者輸入角色名稱的搜尋框、提交按鈕,以及顯示搜尋結果的區塊:

<form id="characterSearch">
  <input type="text" id="query" placeholder="Enter character name" />
  <input type="submit" value="Search" />
</form>
<div id="lastQuery"></div>
<ol id="searchResults"></ol>

接著,我們會撰寫 JavaScript 來實現搜尋和顯示搜尋結果的邏輯。首先,我們會使用之前提到的 PubSub class(這部分程式碼先略過),並訂閱兩個主題:

  1. /search/characterName:當此主題被觸發時,我們會更新 #lastQuery 區塊的文字,顯示目前正在搜尋的角色名稱。
  2. /search/resultSet:當此主題被觸發時,我們會將搜尋結果渲染到 #searchResults 區塊中。

接著要綁定表單的提交事件,當使用者提交表單時,會發布 /search/characterName 主題,並發送 API 請求。當請求成功後,會發布 /search/resultSet 主題。因為我們之前已經訂閱了這兩個主題,因此當主題被觸發時,相關的後續邏輯會自動執行。這種方法能將 API 請求與後續處理邏輯分開,降低耦合度,實現了關注點分離。

class PubSub {
  // ...略
}

const pubsub = new PubSub(); // 建立 pubsub 實例

// 訂閱搜尋角色名稱的主題,並傳入觸發此主題後要執行的邏輯
pubsub.subscribe("/search/characterName", (topic, characterName) => {
  document.getElementById(
    "lastQuery"
  ).innerText = `Searched for: ${characterName}`;
});

// 訂閱搜尋資料準備好的主題,並傳入觸發此主題後要執行的邏輯
pubsub.subscribe("/search/resultSet", (topic, results) => {
  const searchResults = document.getElementById("searchResults");
  searchResults.innerHTML = "";

  // 最多顯示 10 筆資料
  const limitedResults = results.slice(0, 10);

  limitedResults.forEach((character) => {
    const li = document.createElement("li");
    li.innerHTML = `
          <h2>${character.name}</h2>
          <img src="${character.image}" alt="${character.name}" />
          <p>Status: ${character.status}</p>
          <p>Species: ${character.species}</p>
          <p>Gender: ${character.gender}</p>
        `;
    searchResults.appendChild(li);
  });
});

// 綁定表單提交事件、提交後會發布 /search/characterName 主題,並發送 API 請求
document.getElementById("characterSearch").addEventListener("submit", (e) => {
  e.preventDefault();
  const characterName = document.getElementById("query").value.trim();

  if (!characterName) {
    return;
  }

  pubsub.publish("/search/characterName", characterName);

  // 發送 API 請求,當請求成功後就發布 /search/resultSet 主題
  fetch(`https://rickandmortyapi.com/api/character/?name=${characterName}`)
    .then((response) => response.json())
    .then((data) => {
      if (data.results && data.results.length > 0) {
        pubsub.publish("/search/resultSet", data.results);
      } else {
        alert("No characters found.");
      }
    })
    .catch((error) => {
      console.error("Request failed:", error);
    });
});

完整程式碼連結請點此

優點

以 Publish/Subscribe 作為解決方案優點如下:

  • 耦合性低:發布者和訂閱者之間不知道對方的存在,只需透過事件/主題通道來溝通,可減少系統中不同模組間的依賴性
  • 靈活度高:發布者和訂閱者鬆散耦合,可輕鬆增加、移除或更改訂閱者,而不需修改發布者的程式碼,適合需要動態變動的場景
  • 適合廣播通知:可透過一個事件廣播通知多個訂閱者,這對需要同時通知多個訂閱者的情境非常有效

缺點

以 Publish/Subscribe 作為解決方案缺點如下:

  • 難以追蹤事件流:解耦是 Publish/Subscribe 的優點但同時也是缺點,因為訂閱者和發布者間沒有直接的依賴關係,要追蹤事件如何在系統中傳播和處理可能會變得困難,尤其是在大型系統中。另外,難以追蹤事件流也會較難除錯,因為訂閱者的執行與發布者無關,要找出訂閱者的錯誤源可能會更複雜。
  • 延遲風險:如果訂閱者數量過多或事件處理邏輯過於複雜,可能會導致系統反應延遲,影響效能
  • 無保證交付:在某些情況下,訂閱者若在事件發布時不在線,可能會錯過該事件。由於解耦特性,發布者不會察覺,可能需要額外機制來確保事件交付或重試

其他補充

Publish/Subscribe 很常(也適合)被應用在事件驅動的架構或場景中,其他應用案例如:即時事件分發、並行處理與工作流、應用程式的資料串流等...。並不限於前端應用,更多可參考 Pub/Sub Common use cases

Reference


上一篇
[Day 11] Observer 模式
下一篇
[Day 13] Mediator 模式
系列文
30天的 JavaScript 設計模式之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言